/*
 *      Copyright 2012-2013 Battams, Derek
 *       
 *       Licensed under the Apache License, Version 2.0 (the "License");
 *       you may not use this file except in compliance with the License.
 *       You may obtain a copy of the License at
 *
 *          http://www.apache.org/licenses/LICENSE-2.0
 *
 *       Unless required by applicable law or agreed to in writing, software
 *       distributed under the License is distributed on an "AS IS" BASIS,
 *       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *       See the License for the specific language governing permissions and
 *       limitations under the License.
 */
package org.schedulesdirect.api;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.schedulesdirect.api.exception.InvalidResponseException;
import org.schedulesdirect.api.exception.InvalidZipCodeException;
import org.schedulesdirect.grabber.Grabber;
import org.schedulesdirect.grabber.Utils;

import de.schlichtherle.truezip.file.TArchiveDetector;
import de.schlichtherle.truezip.file.TConfig;
import de.schlichtherle.truezip.file.TFile;
import de.schlichtherle.truezip.file.TVFS;
import de.schlichtherle.truezip.fs.archive.zip.JarDriver;
import de.schlichtherle.truezip.socket.sl.IOPoolLocator;

/**
 * An implementation of EpgClient that uses a local zip file as its data source
 * 
 * <p>The zip file to be used must follow a specific format and structure.  Such a zip file
 * can be generated by running the EPG Grabber application.</p>
 * 
 * <p>This implementation has two common uses:
 * <ol>
 *   <li>For development and testing of this API, you can download a zip file of listings data and reuse it instead of constantly hitting the Schedules Direct servers.</li>
 *   <li>For production, applications <b>should always</b> simply download a zip file once a day and reuse that zip file via this client implentation, which provides a simple form a caching.</li>
 * </ol></p>
 * 
 * <p>
 * Most every real world application should only be accessing EPG data via instances of this class.  Apps should
 * download their EPG data once a day using the sdjson grabber application then feeding that generated zip file 
 * into instances of this class to access the EPG data in their apps.
 * </p>
 * @author Derek Battams &lt;derek@battams.ca&gt;
 *
 */
public class ZipEpgClient extends EpgClient {
	static private final Log LOG = LogFactory.getLog(ZipEpgClient.class);
	static {
		// Register the .epg file type as an archive
		TConfig.get().setArchiveDetector(new TArchiveDetector("epg", new JarDriver(IOPoolLocator.SINGLETON)));
	}
	
	static private final Map<File, AtomicInteger> CLNT_COUNT = Collections.synchronizedMap(new HashMap<File, AtomicInteger>());
	
	private File vfs;
	private Map<String, Headend> headends;
	private Map<String, Program> progCache;
	private boolean closed;
	
	/**
	 * Constructor
	 * @param vfs The vfs to be used as the data source for this client implementation
	 * @throws IOException Thrown on any IO error reading the zip file
	 */
	public ZipEpgClient(File vfs) throws IOException {
		super(null);
		progCache = new HashMap<String, Program>();
		this.vfs = vfs;
		try {
			TFile verFile = new TFile(vfs, Grabber.ZIP_VER_FILE);
			if(verFile.exists()) {
				int ver = Integer.parseInt(Utils.readVfsEntry(verFile));
				if(ver != Grabber.ZIP_VER)
					throw new IOException(String.format("Zip file is not expected version! [v=%d; e=%d]", ver, Grabber.ZIP_VER));
			} else
				throw new IOException(String.format("Zip file of version %d required!", Grabber.ZIP_VER));
			LOG.debug(String.format("Zip file format validated! [version=%d]", Grabber.ZIP_VER));
			headends = new HashMap<String, Headend>();
			String heList = Utils.readVfsEntry(vfs, Grabber.HEADENDS_LIST);
			if(heList != null) {
				try {
					JSONArray headends = new JSONArray(heList);
					for(int i = 0; i < headends.length(); ++i) {
						JSONObject o = headends.getJSONObject(i);
						this.headends.put(o.getString(Headend.JSON_ID), new Headend(o, this));
					}
				} catch(JSONException e) {
					throw new IOException("Headends list is invalid!", e);
				}
			}
			AtomicInteger i = CLNT_COUNT.get(vfs);
			if(i == null) {
				i = new AtomicInteger(0);
				CLNT_COUNT.put(vfs, i);
			}
			i.incrementAndGet();
		} finally {
			if(this.vfs != null)
				TVFS.umount(new TFile(this.vfs));
		}
		closed = false;
	}

	/*
	 * The zip argument is ignored in this implementation since the list of available headends is embedded in the zip file used as the source
	 */
	@Override
	protected Headend[] getHeadendsForZip(String zip, String isoCountry) throws InvalidZipCodeException, IOException {
		return getHeadends();
	}
	
	@Override
	public Headend[] getHeadends() throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		return headends.values().toArray(new Headend[0]);		
	}

	@Override
	public Headend getHeadendById(String id) throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		return headends.get(id);
	}

	@Override
	public UserStatus getUserStatus() throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		try {
			return new UserStatus(new JSONObject(Utils.readVfsEntry(vfs, Grabber.USER_DATA)), null, this);
		} catch (InvalidResponseException e) {
			throw new IOException(e);
		} catch (JSONException e) {
			throw new IOException(e);
		}
	}
	
	@Override
	public void close() throws IOException {
		if(!closed) {
			purgeCache();
			AtomicInteger i = CLNT_COUNT.get(vfs);
			int v = i != null ? i.decrementAndGet() : 0;
			if(v == 0) {
				LOG.debug("Calling umount() for " + vfs.toString());
				TVFS.umount(new TFile(vfs));
			} else if(LOG.isDebugEnabled())
				LOG.debug(String.format("Skipped umount() for %s; c=%d", vfs.toString(), i != null ? i.get() : Integer.MIN_VALUE));
			closed = true;
		}
	}
	
	@Override
	protected void finalize() throws Throwable {
		super.finalize();
		close();
	}

	@Override
	protected Airing[] fetchSchedule(Station station) throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		List<Airing> airs = new ArrayList<Airing>();
		try {
			String input = Utils.readVfsEntry(vfs, String.format("schedules/%s.txt", Grabber.scrubFileName(station.getId())));
			if(input != null) {
				JSONArray jarr = new JSONArray(input);
				for(int i = 0; i < jarr.length(); ++i) {
					JSONObject src = jarr.getJSONObject(i);
					Program p = fetchProgram(src.getString("programID"));
					if(p != null)
						airs.add(new Airing(src, p, station));
				}
			} else
				LOG.error(String.format("VFS returned null for station id %s; returning empty schedule!  Zip cache is corrupted or lineup is incomplete on SD servers!", station.getId()));
		} catch (JSONException e) {
			throw new IOException("JSON error!", e);
		}
		return airs.toArray(new Airing[0]);
	}

	@Override
	protected Program fetchProgram(String progId) throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		Program p = progCache.get(progId);
		if(p == null) {
			try {
				String data = Utils.readVfsEntry(vfs, String.format("programs/%s.txt", Grabber.scrubFileName(progId)));
				if(data != null) {
					JSONObject obj = new JSONObject(data);
					String metadata = Utils.readVfsEntry(vfs, String.format("metadata/%s.txt", Grabber.scrubFileName(progId)));
					if(metadata != null)
						obj.put("metadata", new JSONArray(metadata));
					String cachedMd5 = obj.optString("md5", "");
					if(cachedMd5 != null && !"".equals(cachedMd5)) {
						p = new Program(obj);
						progCache.put(progId, p);
					}
				}
			} catch (JSONException e) {
				throw new IOException("JSON error!", e);
			}
		}
		return p;
	}

	@Override
	protected Map<Station, Airing[]> fetchSchedules(Lineup lineup) throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		Map<Station, Airing[]> scheds = new HashMap<Station, Airing[]>();
		for(Station s : lineup.getStations())
			scheds.put(s, fetchSchedule(s));
		return scheds;
	}

	@Override
	protected Map<String, Program> fetchPrograms(String[] progIds) throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		Map<String, Program> progs = new HashMap<String, Program>();
		for(String id : progIds)
			progs.put(id, fetchProgram(id));
		return progs;
	}

	/**
	 * Find the metadata object for the given device name
	 * @param metas The array of metadata objects
	 * @param dev The device name being sought after
	 * @return The metadata object for the device name or null if it could not be found
	 * @throws JSONException On any JSON error encountered
	 */
	protected JSONObject findMetadataForDevice(JSONArray metas, String dev) throws JSONException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		JSONObject val = null;
		for(int i = 0; i < metas.length(); ++i) {
			JSONObject o = metas.getJSONObject(i);
			if(dev.equals(o.optString("device"))) {
				val = o;
				break;
			}
		}
		return val;
	}
	
	@Override
	protected Lineup[] fetchLineups(Headend headend) throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		final String EMPTY_OBJ = "{}";
		JSONObject obj = null;
		Collection<Lineup> lineups = new ArrayList<Lineup>();
		try {
			String input = Utils.readVfsEntry(vfs, String.format("lineups/%s.txt", Grabber.scrubFileName(headend.getId())));
			if(input.length() == 0) {
				input = EMPTY_OBJ;
				LOG.warn(String.format("%s: Received empty response!", headend.getId()));
			}
			obj = new JSONObject(input);
			JSONArray devs = obj.getJSONArray("deviceTypes");
			JSONArray metas = obj.getJSONArray("metadata");
			JSONArray stations = obj.getJSONArray("stations");
			for(int i = 0; i < devs.length(); ++i) {
				String devId = devs.getString(i);
				JSONObject meta = findMetadataForDevice(metas, devId);
				JSONObject devData = obj.optJSONObject(devId);
				JSONArray stationMap = null;
				if(devData != null)
					stationMap = devData.optJSONArray("map");
				String heName = headend.getName();
				if(heName == null)
					heName = headend.getId();
				String heLocation = headend.getLocation();
				if(heLocation == null)
					heLocation = "OTA";
				lineups.add(new Lineup(headend.getId(), heName, heLocation, meta, stations, stationMap, this));
			}
		} catch (JSONException e) {
			LOG.error(String.format("Invalid JSONObject in response! [key=%s]", headend.getId()), e);
			try {
				obj = new JSONObject(EMPTY_OBJ);
			} catch(JSONException je) {  // Just won't ever happen
				throw new RuntimeException(je);
			}
		}
		return lineups.toArray(new Lineup[0]);
	}

	@Override
	public void purgeCache() {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		progCache.clear();
	}

	@Override
	public void purgeCache(Object obj) {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		if(obj instanceof Program)
			progCache.remove(((Program)obj).getId());
	}

	@Override
	public void deleteMessage(Message msg) throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		throw new UnsupportedOperationException("Messages can only be deleted via the NetworkEpgClient!");
	}
	
	/**
	 * Check all downloaded schedules for gaps in the airing schedules
	 * <p>
	 * 	The data received from upstream should never have gaps in the schedules.  If so, that
	 *  is an error that invalidates the data in the local cache and the data should no longer
	 *  be trusted.
	 * </p>
	 * @return Null if no gaps are found in any schedules in the local cache or a two element array of Airing objects representing the first gap found
	 * @throws IOException in case of any IO error during the operation
	 */
	public Airing[] findScheduleGap() throws IOException {
		for(Headend he : headends.values())
			for(Lineup l : he.getLineups())
				for(Station s : l.getStations()) {
					Airing prev = null;
					for(Airing a : s.getAirings())
						if(prev == null || new Date(prev.getGmtStart().getTime() + 1000L * prev.getDuration()).equals(a.getGmtStart()))
							prev = a;
						else
							return new Airing[] {prev, a};
				}
		return null;
	}

	@Override
	public SystemStatus getSystemStatus() throws IOException {
		if(closed)
			throw new IllegalStateException("Instance has already been closed!");
		try {
			JSONObject user = new JSONObject(Utils.readVfsEntry(vfs, Grabber.USER_DATA));
			return new SystemStatus(user.getJSONArray("systemStatus"));
		} catch (InvalidResponseException e) {
			throw new IOException(e);
		} catch (JSONException e) {
			throw new IOException(e);
		}
	}

	@Override
	protected InputStream fetchLogoStream(Station station) throws IOException {
		String url = station.getLogo().getUrl().toString();
		String ext = url.substring(url.lastIndexOf('.') + 1);
		return Utils.readVfsData(vfs, String.format("logos/%s.%s", station.getCallsign(), ext));
	}
}
